Skip to content

@W-20806053 Implement Private Methods#5711

Open
a-chabot wants to merge 32 commits intosalesforce:masterfrom
a-chabot:a-chabot/private-methods-transform
Open

@W-20806053 Implement Private Methods#5711
a-chabot wants to merge 32 commits intosalesforce:masterfrom
a-chabot:a-chabot/private-methods-transform

Conversation

@a-chabot
Copy link
Contributor

@a-chabot a-chabot commented Feb 13, 2026

Details

Link to RFC
Tests PR
Create feature flag in lwc-platform

Does this pull request introduce a breaking change?

  • 😮‍💨 No, it does not introduce a breaking change.

Does this pull request introduce an observable change?

  • 🔬 Yes, it does include an observable change.

GUS work item

W-20806053

a-chabot and others added 7 commits February 13, 2026 16:23
Signed-off-by: a.chabot <a.chabot@salesforce.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: a.chabot <a.chabot@salesforce.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Detect when a user-defined method collides with the reserved
__lwc_component_class_internal_private_ prefix by tracking which methods
the forward transform renames and verifying the reverse transform only
restores those same methods. Throws a descriptive error on mismatch.

Made-with: Cursor
…thod transforms

Add 10 new tests covering forward-only output verification, combined
flags, method ordering, default/destructuring params, empty bodies,
same-name coexistence, and intermediate plugin scenarios (body mutation,
method injection, near-miss prefix matching).

Made-with: Cursor
@a-chabot a-chabot marked this pull request as ready for review February 26, 2026 18:35
@a-chabot a-chabot requested a review from a team as a code owner February 26, 2026 18:35
Comment on lines +22 to +29
Program: {
enter(path: NodePath<types.Program>, state: LwcBabelPluginPass) {
const transformedNames = new Set<string>();

// Transform private methods BEFORE any other plugin processes them
path.traverse(
{
ClassPrivateMethod(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have a Program visitor that traverses with a ClassPrivateMethod visitor instead of just a top-level ClassPrivateMethod visitor?

Modifying nodes can trigger re-evaluation of visitors. If I understand correctly, this means that the current implementation will do a full traversal looking for ClassPrivateMethod whenever a Program is re-evaluated, even if it's not a changed ClassPrivateMethod that triggered re-evaluation. That seems like not what we want.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you switch the top-level ClassPrivateMethod visitor and it causes an infinite loop. The cause of this is that the forward and reverse transforms are both active during the same Babel traversal (Babel merges all plugin visitors into a single pass).

When a top-level ClassPrivateMethod visitor replaces a node with ClassMethod, the reverse transform's ClassMethod visitor immediately fires on the replacement and converts it back to ClassPrivateMethod, which triggers the forward visitor again

Loop: ClassPrivateMethod --> ClassMethod --> ClassPrivateMethod --> ClassMethod --> ....

The Program + path.traverse() pattern avoids this because it's a manual one-shot traversal that completes all forward replacements before Babel's main traversal reaches any class member nodes. By the time the reverse transform's ClassMethod visitor is active, there are no ClassPrivateMethod nodes left to fight over.

You're right that Babel can re-evaluate a Program visitor when descendant nodes are modified. I split out the forward transform to be its own separate plugin, whose only visitor is Program. Since the forward transform itself doesn't modify any Program-level nodes (it only replaces ClassPrivateMethod nodes deep inside the tree), it won't trigger its own re-evaluation.

Comment on lines +69 to +94
// Preserve TypeScript annotations and source location when present
if (node.returnType != null) {
classMethod.returnType = node.returnType;
}
if (node.typeParameters != null) {
classMethod.typeParameters = node.typeParameters;
}
if (node.loc != null) {
classMethod.loc = node.loc;
}
// Preserve TypeScript/ECMAScript modifier flags (excluded from t.classMethod() builder)
if (node.abstract != null) {
classMethod.abstract = node.abstract;
}
if (node.access != null) {
classMethod.access = node.access;
}
if (node.accessibility != null) {
classMethod.accessibility = node.accessibility;
}
if (node.optional != null) {
classMethod.optional = node.optional;
}
if (node.override != null) {
classMethod.override = node.override;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is all of this information still required at this point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These aren't required by the builder but I do think should be preserved when switching between node types (between ClassMethod and ClassPrivateMethod nodes)
https://babeljs.io/docs/babel-types?utm_source=chatgpt.com#classmethod

Copy link
Member

@ekashida ekashida left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are allowing for intermediate broken state between the forward and reverse transforms, I think we should have some end-to-end tests in the form of fixtures to ensure internal usage of these private methods remain intact.

expect(result!.code).not.toContain('__lwc_component_class_internal_private_');
});

test('private getters and setters are not transformed', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't seem to be testing what it's saying it's testing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transform seems to support accessor methods, which I'm personally fine with, but we explicitly said we wouldn't in the RFC.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about DX, should we have informative errors for static and non-static private properties and accessor methods? Something like, "Private methods are the only private member type that is currently supported."?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, added LWC1214 to handle this

a-chabot added 9 commits March 2, 2026 12:36
…served prefix

More precise guidance: "cannot start with reserved prefix `__lwc_`" instead of
"conflicts with internal naming conventions".

Made-with: Cursor
Remove private method example added for testing; not needed in the playground.

Made-with: Cursor
Move the constant literal out of the visitor body into a module-level
METHOD_KIND constant.

Made-with: Cursor
… transforms

Deduplicate the repeated if-node.X-!= null property copying into a single
helper in utils.ts, used by both the forward and reverse transforms.

Made-with: Cursor
…ent plugins

Both transforms are now standalone Babel plugins returning PluginObj directly,
re-exported from index.ts. The forward transform is no longer bundled inside
the main LwcClassTransform Program.enter; instead it runs as a separate plugin
before the main plugin in the pipeline.

Made-with: Cursor
Add ! to the transformSync() return in each helper function so callers
can use result.code instead of result!.code everywhere.

Made-with: Cursor
Combine split expect(code).toContain() calls into single assertions that
match the full substring (e.g. 'static async #fetch(url, opts = {})').

Made-with: Cursor
Verify that private fields (#count, #name) are not affected by the
private method transform, both alone and alongside private methods.

Made-with: Cursor
Validate that private method call sites, private field references in
method bodies, and mixed field/method classes all behave correctly
through the transform pipeline without leaking prefixed names.

Made-with: Cursor
@a-chabot a-chabot force-pushed the a-chabot/private-methods-transform branch from ad63005 to 3ea2b0a Compare March 2, 2026 19:48
Add LWC1214 error for unsupported private member types (fields and
accessor methods). Update the forward transform to throw informative
errors instead of silently passing through. Replace affected unit tests
with error-expectation tests. Add fixture-based end-to-end tests that
run the full pipeline (forward transform, LWC plugin, class-properties
plugin, reverse transform) to verify private methods survive round-trip.

Made-with: Cursor
@a-chabot a-chabot force-pushed the a-chabot/private-methods-transform branch from 3ea2b0a to f284247 Compare March 2, 2026 20:14
a-chabot added 2 commits March 2, 2026 15:38
Made-with: Cursor
Extend the private method transform to also handle MemberExpression
nodes with PrivateName properties (e.g. this.#foo()), not just
ClassPrivateMethod definitions. This ensures the intermediate AST is
fully consistent—both definitions and call sites use the prefixed
public name—so intermediate plugins can process the code correctly.

Made-with: Cursor
Comment on lines +115 to +126
MemberExpression(memberPath: NodePath<types.MemberExpression>) {
const property = memberPath.node.property;
if (t.isPrivateName(property)) {
const baseName = (property as types.PrivateName).id.name;
if (privateMethodBaseNames.has(baseName)) {
const prefixedName = `${PRIVATE_METHOD_PREFIX}${baseName}`;
memberPath
.get('property')
.replaceWith(t.identifier(prefixedName));
}
}
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to transform invocations (forward)

Comment on lines +84 to +100
MemberExpression(path: NodePath<types.MemberExpression>, state: LwcBabelPluginPass) {
const property = path.node.property;
if (!t.isIdentifier(property) || !property.name.startsWith(PRIVATE_METHOD_PREFIX)) {
return;
}

const forwardTransformedNames: Set<string> | undefined = (
state.file.metadata as any
)[PRIVATE_METHOD_METADATA_KEY];

if (!forwardTransformedNames || !forwardTransformedNames.has(property.name)) {
return;
}

const originalName = property.name.replace(PRIVATE_METHOD_PREFIX, '');
path.get('property').replaceWith(t.privateName(t.identifier(originalName)));
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to transform invocations (reverse)

a-chabot added 3 commits March 4, 2026 11:03
…ler flag

The private method round-trip transform is now only applied when
enablePrivateMethods is explicitly set to true in TransformOptions,
allowing the feature to be restricted to internal components.

Made-with: Cursor
… playground

Add enablePrivateMethods to RollupLwcOptions and forward it to the
compiler's transformSync call. Enable it in the playground rollup
config and add a private method to the counter component for testing.

Made-with: Cursor
Remove test private method from counter component and revert
playground rollup config back to default lwc() options.

Made-with: Cursor
a-chabot and others added 8 commits March 4, 2026 13:28
…-flag

feat: gate private method transform behind enablePrivateMethods compi…
Remove fixture test files from this branch; they now live on
a-chabot/private-methods-fixture-tests.

Made-with: Cursor
Remove 16 inline tests from private-method-transform.spec.ts that
are now covered by fixture-based tests on the fixture branch.

Made-with: Cursor
Remove 13 inline tests from private-method-transform.spec.ts that are
now redundant with fixture tests (8 already covered, 5 converted to new
fixtures). Add comments to the 10 remaining inline tests explaining why
they must stay inline (custom pipelines, forward-only, reverse-only).

Made-with: Cursor
Consolidate the individual "Kept inline" comments into a single
explanatory note at the top of the file describing why these tests
cannot be fixture tests.

Made-with: Cursor
Test that cross-class #privateName access is a parse error, and that a
spoofed mangled name definition is harmlessly round-tripped back to a
class-scoped private method.

Made-with: Cursor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will get replaced with the tests that are in this PR: a-chabot#2

The private-methods fixtures directory only contains .gitkeep on this branch,
causing CI to fail with 'No test found in suite'. Skipping the test suite
temporarily until the fixture tests from a-chabot/private-method-fixture-tests
are merged.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
return { code };
}

describe.skip('private-methods fixtures', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped for now because the fixtures folder is empty but once a-chabot#2 gets merged in, this will get re-enabled

@a-chabot a-chabot changed the title @W-20806046 Implement Private Methods @W-20806053 Implement Private Methods Mar 5, 2026
Copy link
Member

@ekashida ekashida left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants